AI 從會議逐字稿抓出一件待辦,人按了批准,程式去建立。呼叫送出去,畫面一直轉,沒回應。
這時候只有兩個選擇,兩個都不對。不重送,那筆待辦可能根本沒建。重送,可能建成兩筆。
今天做的就是讓「重送」變安全:第二次送出去,對方認得這是同一件事,把第一次的結果還我,不再多建一筆。
我用一個假的待辦服務跑了一遍。同一份批准,兩個程序先後送出。服務收到 2 次請求,只建立 1 次,最後只有 1 筆待辦。兩張收據上的編號一樣。
這篇文章要做到的,就這一行:2、1、1。
用生活的例子想。網購按「送出訂單」,畫面一直轉。再按一次。
我要的是兩件事同時成立:只成立一張訂單;第二次按下去也要有回應,不能當作沒按過。
技術上這叫冪等(idempotent):同一個請求送幾次,效果都跟送一次一樣。
「效果一樣」不代表「沒有紀錄」。RFC 9110 定義冪等時特別講了這點:伺服器端的預期作用跟送一次相同,伺服器仍然可以逐次留下日誌。
這句話直接決定了今天怎麼驗收。
第 13 天最後交出一筆「行動候選」:程式從逐字稿裡整理出「某人說要補檢查項目」,驗過來源、驗過格式,停在「可以送人審」。它沒有被批准,也沒有任何工具可以呼叫。
今天補三個零件,讓它能真的變成一筆待辦,而且重送不會變兩筆:
1/ 一份獨立的批准,綁死候選的版本。
2/ 一個「意圖編號」,代表「這一次建立」這個動作。
3/ 一個假的待辦服務,會記住第一次的結果。
為什麼用假的服務?因為今天要驗的是控制邏輯,不是真的去碰誰的待辦清單。假服務沒有網路呼叫,所有狀態都存在本機檔案裡,可以反覆跑、反覆弄壞。
第 12 天也在「去重」:同一份人工決定被重送兩次,領域狀態只套用一次(檔案仍會重寫)。但那次的重送在自己家門口就擋掉了,決定根本沒出門。今天的重送刻意讓它出門,走到待辦服務的門口。因為真正的問題是外部的東西被重試時會不會多建一筆;在家門口擋掉,回答不了外面那一題。
我讓假服務把三件事分開數:收到幾次請求、真的執行了幾次「建立」、最後有幾筆待辦。
第一次送完,三個數字是 1、1、1。第二個程序用同一份批准重送,變成 2、1、1。
只看最後一個數字不夠。「最後只有一筆」可能是建了兩筆再蓋掉一筆。三個數字一起看,才能確定:重送真的到了(第一個數字變 2),建立沒有再發生(後兩個數字沒動)。
「沒有重複」是一句話。「2、1、1」是三個可以檢查的數字。
從候選變成待辦,中間會經過四份資料。我刻意不把它們合併。
候選是第 13 天的產物,它說「逐字稿裡有這件事、來自哪一版來源」。建立請求說「這次準備建立什麼」:標題、負責人、期限,加上意圖編號。批准說「誰批准了哪一份候選、哪一份請求、可以用什麼能力」。執行收據是送去服務之後拿回來的結果:建立了、重播了、還是衝突。
為什麼不能把「已批准」直接寫回候選裡?候選是從模型輸出驗出來的資料,批准是另一個人做的權限決定,收據是服務回來的觀察結果。三件事混在同一個物件,其中一個要換版本時,另外兩個也跟著壞。
新增的三份(請求、批准、收據)各有一份 schema 當文件契約;執行時跑的是手寫的嚴格格式檢查,不是通用的 JSON Schema 驗證器。
節錄自保存的合成請求,另有 candidate_ref 綁定來源版本:
{
"schema_version": "task-request.v1",
"fixture_kind": "synthetic-task-request-no-real-provider",
"request_id": "task-request-synthetic-001",
"intent_id": "intent-synthetic-001",
"operation": "create_task",
"provider": "fake-task-provider.v1",
"task": {
"title": "補上通知設定的檢查項目",
"assignee_person_ref": null,
"due_at": null
}
}
負責人是 null,期限是 null。第 13 天只知道有個匿名說話者承諾要做這件事,不知道他是誰,逐字稿裡也沒提日期。今天只能原樣保留。不能因為要建待辦,就猜一個人名或日期填進去。
批准不是一張到處能用的通行證。程式在呼叫服務之前,會重算並核對四件事:候選的編號跟內容摘要(內容的雜湊值)對不對、候選引用的來源版本對不對、建立請求的編號跟內容摘要對不對、批准給的能力是不是剛好「建立假待辦」這一項。
任何一項不對,程式停在門外,服務那邊的請求數維持 0。候選換了版本、來源換了版本、請求內容被改、能力被換成「發布」,四種情況都連門都進不去。
批准檔裡有個「誰批准的」欄位,只是給測試回查用的。這次沒有登入、沒有簽章,不能證明是真人或有授權。
這是今天最重要的一段。
意圖編號回答:這是不是同一次「建立」的動作?內容摘要回答:這次送來的內容,跟第一次一樣嗎?
假服務收到請求時,先查有沒有同一個意圖編號的紀錄:
| 有沒有同一個意圖編號 | 內容跟第一次比 | 結果 | 服務做了什麼 |
|---|---|---|---|
| 沒有 | 不用比 | 建立(created) |
建一筆,記住這次的內容摘要跟結果 |
| 有 | 一樣 | 重播(replayed) |
不建,把第一次的結果還你 |
| 有 | 不一樣 | 衝突(idempotency_conflict) |
不建,也不蓋掉第一筆,回報衝突 |
第三列是很多人會漏掉的。拿到同一個意圖編號,不代表永遠回成功。服務得記住第一次的內容摘要,才能在誤用同一個編號、卻送了不同內容時,明確回「這不對」,而不是默默把舊結果還回去。
還有第四種組合:不同的意圖編號、一樣的內容。這時候服務會建兩筆。
這條很容易被當成 bug。兩個不同的意圖編號,代表呼叫的人明確表達了「我要建兩次」。內容剛好一樣,可能是真的要兩筆一樣的待辦。服務不該替他猜。
第 12 天用同一份 AWS 文章擋過「同一個決定識別碼換內容」。同一個原則到了外部寫入這裡,多了一層:識別碼要由呼叫者給,因為只有呼叫者知道這是重試還是再來一筆。老實說,只用內容摘要當去重的鍵很誘人,可以少一個欄位;但省掉的正是「呼叫者的意圖」這個資訊。
JavaScript 物件的欄位順序可以不一樣,內容完全相同。如果直接對原始字串算摘要,這樣的兩份請求會被判成不同內容,第二次就變衝突。
做法沿用第 11 天:先把物件的鍵排序,再算 SHA-256。陣列的順序保留,因為陣列位置可能有意義。專項測試把整份請求的欄位順序打亂,摘要一樣,第二次還是重播,待辦編號還是同一個。
服務的狀態檔裡有四個清單:每一次收到的請求、每一次真的執行的建立、目前存在的待辦、每個意圖編號對到的第一次內容摘要跟結果。
四個清單,才回答得了四個問題:第二次有沒有真的到?有沒有再建?最後有幾筆?重播回的是哪一次的結果?
這四個問題各自有獨立的欄位可查。換成 already_processed: true,稽核時只能回答「處理過」,回答不了「處理成什麼」。
第一個程序拿著批准跟請求,呼叫假服務,拿到「建立」,狀態寫進檔案,程序結束。第二個程序啟動,讀回那份檔案,原樣重送同一份批准跟請求,拿到「重播」。
兩張收據的待辦編號都是 fake-task-0001。三個數字是 2、1、1。
這裡要老實講邊界:兩個程序是先後跑的,不是同時。狀態檔用「先寫暫存檔再改名」的方式存(避免讀到寫一半的檔案),只有單機、單一寫入者。沒有資料庫交易,沒有處理兩個程序同時進來的情況。
跨程序那一段只驗到第二次的 2、1、1。下面這三次是同一個程序內連跑的固定序列:
| 第幾次 | 意圖編號 | 內容 | 收據 | 請求、建立、待辦 |
|---|---|---|---|---|
| 1 | 同一個 | 原內容 | 建立 | 1、1、1 |
| 2 | 同一個 | 原內容 | 重播 | 2、1、1 |
| 3 | 同一個 | 改過的內容 | 衝突 | 3、1、1 |
第三次值得多看一眼。內容被改了,而且改動本身有另外拿到一份批准。服務照樣擋:這個意圖編號已經綁著第一次的摘要,內容不同就是衝突。批准解決的是「有沒有資格呼叫」,冪等解決的是「同一件事有沒有做兩次」,兩個問題不能互相抵掉。
另一條路:兩個不同的意圖編號、一樣的內容,結果是 2、2、2。兩筆都建。
這兩組放在一起看,就知道今天不是用「內容像不像」猜重複。是呼叫者先給意圖編號,服務再用摘要檢查這個編號有沒有被誤用。
一條流程會不會壞,不是看它順跑一次,是看它被亂送的時候會不會做錯事。
| 弄壞什麼 | 在哪裡停 | 服務收到幾次請求 |
|---|---|---|
| 沒有批准,或拿第 12 天的身分決定冒充 | 批准格式檢查 | 0 |
| 候選或來源換版了,沿用舊批准 | 候選綁定檢查 | 0 |
| 改動已批准的請求內容 | 請求摘要檢查 | 0 |
| 把能力換成「發布」 | 能力白名單 | 0 |
| 同一意圖編號、不同內容 | 服務的冪等紀錄 | 多 1 次,不建 |
| 不同意圖編號、相同內容 | 服務的建立 | 多 1 次,建第二筆 |
前四種根本沒資格呼叫,服務連請求都沒收到。後兩種必須真的走進服務,才驗得到冪等契約有沒有守住。
Day 14 的測試檔這次跑 14 條全過,含正常路徑、這六種弄壞、欄位順序、跨程序,以及保存的結果跟程式重算一致。
冪等鍵在付款和任務類 API 很常見,做法卻不一樣。挑三個講:
Stripe 會保存第一次的回應,同一個鍵再送就回那份;參數不一樣就回錯誤;鍵放至少 24 小時後可能被清掉。今天的假服務走的就是這一型。
Google 的 Cloud Tasks 讓呼叫者自己指定任務編號,同一個佇列近期已有這個編號就回「已存在」。副作用一樣只建一次,但第二次回的不是第一次的結果,是一個錯誤碼。
Adyen 的鍵以公司帳戶為範圍,保存七到十四天;兩個一樣的鍵同時進來,可能回暫時性錯誤要求等一下再送。
還有 PayPal、Square、Amazon ECS、Google 的 AIP-155,各有各的規定,連結都在文末。拉出來的共同點只有一個:只建立一次。至於鍵保存多久、作用範圍多大、兩個同時到怎麼辦、第二次回什麼,每家都不同。正式接一個服務的時候,這四件事要自己去查文件,不能只說「我有帶 UUID」。
順帶一句:常看到的 Idempotency-Key 這個 HTTP 標頭,IETF 的草案在 2026-04-18 過期了,沒有變成 RFC。可以參考它的設計,不能當它是標準。
今天用 JSON 檔當服務狀態,只能一個程序接一個程序跑。正式環境至少要換成資料庫:用「呼叫者範圍加意圖編號」建唯一約束,兩筆第一次紀錄就不可能同時落地;PostgreSQL 的 INSERT ... ON CONFLICT 可以原子地(要嘛整筆成立、要嘛完全不動)處理這個衝突。
唯一約束只回答「第二筆插不插得進去」。內容一不一樣、第一次的結果存哪裡、衝突回什麼,還是要自己定。而且就算本地紀錄寫得再穩,也沒辦法把外部服務的建立跟本地紀錄綁進同一筆交易。外部建成功了、回應卻沒收到,之後怎麼對帳,是第 26 天的題目。
證明了:
1/ 同一份批准,兩個程序先後送,服務收到 2 次、建 1 次、留 1 筆,兩張收據同一個編號。
2/ 同一意圖換內容會衝突,不會默默回舊結果;不同意圖同內容會建兩筆。
3/ 欄位順序不同的同一份請求,摘要一樣,還是重播。
4/ 沒批准、換版、改請求、擴權,四種都停在門外,服務請求數是 0。
沒證明的:
▸ 任何真實的待辦服務。今天沒有網路呼叫,服務是本機假的。
▸ 兩個程序同時進來會怎樣。今天只跑了先後。
▸ 恰好一次。單機檔案不證明跨檔交易或當機恢復。
▸ 鍵要保存多久、範圍多大。今天的狀態不過期,正式服務要自己定。